查看原文
其他

数据库中间件详解

ImportNew 2022-11-11

The following article is from 田守枝的技术博客 Author 田守枝

1 数据库拆分过程及挑战


互联网当下的数据库拆分过程基本遵循的顺序是:垂直拆分、读写分离、分库分表(水平拆分)。每个拆分过程都能解决业务上的一些问题,但同时也面临了一些挑战。 


1.1 垂直拆分


对于一个刚上线的互联网项目来说,由于前期活跃用户数量并不多,并发量也相对较小,所以此时企业一般都会选择将所有数据存放在一个数据库中进行访问操作。举例来说,对于一个电商系统,其用户模块和产品模块的表刚开始都是位于一个库中。



其中:user、useraccount 表属于用户模块,productcategory、product 表属于产品模块。


刚开始,可能公司的技术团队规模比较小,所有的数据都位于一个库中。随着公司业务的发展,技术团队人员也得到了扩张,划分为不同的技术小组,不同的小组负责不同的业务模块。例如 A 小组负责用户模块,B 小组负责产品模块。此时数据库也迎来了第一次拆分:垂直拆分。


这里的垂直拆分,指的是将一个包含了很多表的数据库,根据表的功能的不同,拆分为多个小的数据库,每个库包含部分表。下图演示将上面提到的 db_eshop 库,拆分为 db_user 库和 db_product 库。



通常来说,垂直拆分,都是根据业务来对一个库中的表进行拆分的。关于垂直拆分,还有另一种说法,将一个包含了很多字段的大表拆分为多个小表,每个表包含部分字段,这种情况在实际开发中基本很少遇到。


垂直拆分的另一个典型应用场景是服务化(SOA)改造。在服务化的背景下,除了业务上需要进行拆分,底层的存储也需要进行隔离。 垂直拆分会使得单个用户请求的响应时间变长,原因在于,在单体应用的场景下,所有的业务都可以在一个节点内部完成,而垂直拆分之后,通常会需要进行RPC调用。然后虽然单个请求的响应时间增加了,但是整个服务的吞吐量确会大大的增加。


1.2 读写分离


随着业务的不断发展,用户数量和并发量不断上升。这时如果仅靠单个数据库实例来支撑所有访问压力,几乎是在 自寻死路 。以产品库为例,可能库中包含了几万种商品,并且每天新增几十种,而产品库每天的访问了可能有几亿甚至几十亿次。数据库读的压力太大,单台 MySQL 实例扛不住,此时大部分 MySQL DBA 就会将数据库设置成 读写分离状态 。也就是一个 Master 节点(主库)对应多个 Slave 节点(从库)。可以将Slave 节点的数据理解为 Master 节点数据的全量备份。



Master 节点接收用户的写请求,并写入到本地二进制文件(binary log)中。Slave 通过一个 I/O 线程与 Master 建立连接,发送 binlog dump 指令。Master 会将 binlog 数据推送给 Slave,Slave 将接收到的 binlog 保存到本地的中继日志(relay log)中,最后,Slave 通过另一个线程 SQL thread 应用本地的 relay log,将数据同步到 Slave 库中。


关于 MySQL 主从复制,内部包含很多细节。例如 binlog 格式分为 statement、row 和 mixed,binlog 同步方式又可以划分为:异步、半同步和同步。复制可以基于 binlogFile+position,也可以基于 GTID。通常,这些都是 DBA 负责维护的,业务 RD 无感知。


在 DBA 将 MySQL 配置成主从复制集群的背景下,开发同学所需要做的工作是:当更新数据时,应用将数据写入 Master 主库,主库将数据同步给多个 Slave 从库。当查询数据时,应用选择某个 Slave 节点读取数据。



1.2.1 读写分离的优点


这样通过配置多个 Slave 节点,可以有效的避免过大的访问量对单个库造成的压力。


1.2.2 读写分离的挑战


1) 对于 DBA 而言,多了很多集群运维工作


例如集群搭建、主从切换、从库扩容、缩容等。例如 Master 配置了多个 Slave 节点,如果其中某个 Slave 节点挂了,那么之后的读请求,我们应用将其转发到正常工作的 Slave 节点上。另外,如果新增了 Slave 节点,应用也应该感知到,可以将读请求转发到新的 Slave 节点上。


2) 对于开发人员而言


  • 基本读写分离功能:对 SQL 类型进行判断,如果是 select 等读请求,就走从库,如果是 insert、update、delete 等写请求,就走主库。

  • 主从数据同步延迟问题:因为数据是从 Master 节点通过网络同步给多个 Slave 节点,因此必然存在延迟。因此有可能出现我们在 Master 节点中已经插入了数据,但是从 Slave 节点却读取不到的问题。对于一些强一致性的业务场景,要求插入后必须能读取到,因此对于这种情况,我们需要提供一种方式,让读请求也可以走主库,而主库上的数据必然是最新的。

  • 事务问题:如果一个事务中同时包含了读请求(如 select)和写请求(如 insert),如果读请求走从库,写请求走主库,由于跨了多个库,那么本地事务已经无法控制,属于分布式事务的范畴。而分布式事务非常复杂且效率较低。因此对于读写分离,目前主流的做法是,事务中的所有 SQL 统一都走主库,由于只涉及到一个库,本地事务就可以搞定。

  • 感知集群信息变更:如果访问的数据库集群信息变更了,例如主从切换了,写流量就要到新的主库上;又例如增加了从库数量,流量需要可以打到新的从库上;又或者某个从库延迟或者失败率比较高,应该将这个从库进行隔离,读流量尽量打到正常的从库上。 


1.3 分库分表


经过垂直分区后的 Master / Salve 模式完全可以承受住难以想象的高并发访问操作,但是否可以永远高枕无忧了?


答案是否定的。一旦业务表中的数据量大了,从维护和性能角度来看,无论是任何的 CRUD 操作,对于数据库而言都是一件极其耗费资源的事情。即便设置了索引, 仍然无法掩盖因为数据量过大从而导致的数据库性能下降的事实 ,因此这个时候 MySQL DBA 或许就该对数据库进行水平分区 (sharding,即分库分表 )。经过水平分区设置后的业务表,必然能够将原本一张表维护的海量数据分配给 N 个子表进行存储和维护。


水平分表从具体实现上又可以分为3种:只分表、只分库、分库分表,下图展示了这三种情况:


 

 1) 只分表


将 DB 库中的 user 表拆分为 2 个分表,user_0 和 user_1,这两个表还位于同一个库中。适用场景:如果库中的多个表中只有某张表或者少数表数据量过大,那么只需要针对这些表进行拆分,其他表保持不变。


2) 只分库


将 DB 库拆分为 db_0 和 db_1 两个库,同时在 db_0 和 db_1 库中各自新建一个 user 表,db_0.user 表和 db_1.user 表中各自只存原来的 db.user 表中的部分数据。


3) 分库分表


将 DB 库拆分为 db_0 和 db_1 两个库,db_0 中包含 user_0、user_1 两个分表,db_1中包含 user_2、user_3 两个分表。下图演示了在分库分表的情况下,数据是如何拆分的:假设 DB 库的 user 表中原来有 4000 万条数据,现在将db库拆分为 2 个分库 db_0 和 db_1,user 表拆分为 user_0、user_1、user_2、user_3 四个分表,每个分表存储 1000 万条数据。



1.3.1 分库分表的好处


如果说读写分离实现了数据库读能力的水平扩展,那么分库分表就是实现了写能力的水平扩展。   


1) 存储能力的水平扩展


在读写分离的情况下,每个集群中的 Master 和 Slave 基本上数据是完全一致的,从存储能力来说,在存在海量数据的情况下,可能由于磁盘空间的限制,无法存储所有的数据。而在分库分表的情况下,我们可以搭建多个 MySQL 主从复制集群,每个集群只存储部分分片的数据,实现存储能力的水平扩展。


2) 写能力的水平扩展


在读写分离的情况下,由于每个集群只有一个 Master,所有的写操作压力都集中在这一个节点上,在写入并发非常高的情况下,这里会成为整个系统的瓶颈。


而在分库分表的情况下,每个分片所属的集群都有一个 Master 节点,都可以执行写入操作,实现写能力的水平扩展。此外减小建立索引开销,降低写操作的锁操作耗时等,都会带来很多显然的好处。 


1.3.2 分库分表的挑战


分库分表的挑战主要体现在四个方面:基本的数据库增删改功能、分布式 ID、分布式事务、动态扩容。下面逐一进行讲述。 


挑战 1:基本的数据库增删改功能   


对于开发人员而言,虽然分库分表的,但是其还是希望能和单库单表那样的去操作数据库。例如我们要批量插入四条用户记录,并且希望根据用户的 id 字段,确定这条记录插入哪个库的哪张表。例如 1 号记录插入 user1 表,2 号记录插入 user2 表,3 号记录插入 user3 表,4 号记录插入 user0 表,以此类推。


SQL 如下所示:



insert into user(id,name) values (1,”tianshouzhi”),(2,”huhuamin”), (3,”wanghanao”),(4,”luyang”)


这样的 SQL 明显是无法执行的,因为我们已经对库和表进行了拆分。这种 SQL 语法只能操作 MySQL 的单个库和单个表。所以必须将 SQL 改成 4 条如下所示,然后分别到每个库上去执行。


insert into user0(id,name) values  (4,”luyang”)insert into user1(id,name) values (1,”tianshouzhi”)insert into user2(id,name) values (2,”huhuamin”)insert into user3(id,name) values (3,”wanghanao”)

具体流程可以用下图进行描述:



解释如下:


  • SQL 解析:首先对 SQL 进行解析,得到需要插入的四条记录的 id 字段的值分别为 1,2,3,4。

  • SQL 路由:SQL 路由包括库路由和表路由。库路由用于确定这条记录应该插入哪个库,表路由用于确定这条记录应该插入哪个表。

  • SQL 改写:因为一条记录只能插入到一个库中,而上述批量插入的语法将会在 每个库中都插入四条记录,明显是不合适的。因此需要对 SQL 进行改写,每个库只插入一条记录。

  • SQL 执行:一条 SQL 经过改写后变成了多条 SQL,为了提升效率应该并发的到不同的库上去执行,而不是按照顺序逐一执行。

  • 结果集合并:每个 SQL 执行之后,都会有一个执行结果,我们需要对分库分表的结果集进行合并,从而得到一个完整的结果。


挑战 2:分布式 ID


在分库分表后,我们不能再使用 MySQL 的自增主键。因为在插入记录的时候,不同的库生成的记录的自增 id 可能会出现冲突。因此需要有一个全局的 id 生成器。目前分布式id有很多中方案,其中一个比较轻量级的方案是 Twitter 的 Snowflake 算法。


挑战 3:分布式事务


分布式事务是分库分表绕不过去的一个坎,因为涉及到了同时更新多个分片数据。例如上面的批量插入记录到四个不同的库,如何保证要么同时成功,要么同时失败。关于分布式事务,MySQL 支持 XA 事务,但是效率较低。柔性事务是目前比较主流的方案,柔性事务包括:最大努力通知型、可靠消息最终一致性方案以及 TCC 两阶段提交。但是无论 XA 事务还是柔性事务,实现起来都是非常复杂的。


挑战 4:动态扩容


动态扩容指的是增加分库分表的数量。例如原来的 user 表拆分到两个库的四张表上。现在我们希望将分库的数量变为 4 个,分表的数量变为 8 个。这种情况下一般要伴随着数据迁移。


例如,在 4 张表的情况下,id 为 7 的记录,7%4=3,因此这条记录位于 user3 这张表上。


但是,现在分表的数量变为了 8 个,而 7%8=0,而 user0 这张表上根本就没有 id=7 的这条记录,因此如果不进行数据迁移的话,就会出现记录找不到的情况。


本教程后面将会介绍一种在动态扩容时不需要进行数据迁移的方案。


1.4 小结


在上面我们已经看到了,读写分离和分库分表带来的好处,但是也面临了极大的挑战。如果由业务开发人员来完成这些工作,难度比较大。因此就有一些公司专门来做一些数据库中间件,对业务开发人员屏蔽底层的繁琐细节,开发人员使用了这些中间件后,不论是读写分离还是分库分表,都可以像操作单库单表那样去操作。


下面,我们将介绍 主流的数据库中间件设计方案和实现。 


2 主流数据库中间件设计方案


数据库中间件的主要作用是向应用程序开发人员屏蔽读写分离和分库分表面临的挑战,并隐藏底层实现细节,使得开发人员可以像操作单库单表那样去操作数据。在介绍分库分表的主流设计方案前,我们首先回顾一下在单个库的情况下,应用的架构,可以用下图进行描述: 



可以看到在操作单库单表的情况下,我们是直接在应用中通过数据连接池(connection pool)与数据库建立连接,进行读写操作。而对于读写分离和分库分表,应用都要操作多个数据库实例,在这种情况下,我们就需要使用到数据库中间件。


2.1 设计方案


典型的数据库中间件设计方案有两种:proxy、smart-client。下图演示了这两种方案的架构:



可以看到不论是 proxy 还是 smart-client,底层都操作了多个数据库实例。不论是分库分表,还是读写分离,都是在数据库中间件层面对业务开发同学进行屏蔽。


2.1.1 proxy 模式


我们独立部署一个代理服务,这个代理服务背后管理多个数据库实例。而在应用中,我们通过一个普通的数据源(c3p0、druid、dbcp 等)与代理服务器建立连接,所有的 SQL 操作语句都是发送给这个代理,由这个代理去操作底层数据库,得到结果并返回给应用。在这种方案下,分库分表和读写分离的逻辑对开发人员是完全透明的。


优点:


1) 多语言支持


也就是说,不论你用的 PHP、Java 或是其他语言,都可以支持。以 MySQL 数据库为例,如果 proxy 本身实现了 MySQL 的通信协议,那么你可以就将其看成一个 MySQL  服务器。MySQL 官方团队为不同语言提供了不同的客户端却动,如 Java 语言的 mysql-connector-java,Python 语言的 mysql-connector-python 等等。因此不同语言的开发者都可以使用 MySQL 官方提供的对应的驱动来与这个代理服务器建通信。


2) 对业务开发同学透明


由于可以把 proxy 当成 MySQL 服务器,理论上业务同学不需要进行太多代码改造,既可以完成接入。


缺点:


1) 实现复杂


因为 proxy 需要实现被代理的数据库 Server 端的通信协议,实现难度较大。通常我们看到一些 proxy 模式的数据库中间件,实际上只能代理某一种数据库,如 MySQL。几乎没有数据库中间件,可以同时代理多种数据库(SQLServer、PostgreSQL、Oracle)。


2) proxy 本身需要保证高可用


由于应用本来是直接访问数据库,现在改成了访问 proxy,意味着 proxy 必须保证高可用。否则,数据库没有宕机,proxy 挂了,导致数据库无法正常访问,就尴尬了。 


3) 租户隔离


可能有多个应用访问 proxy 代理的底层数据库,必然会对 proxy 自身的内存、网络、CPU 等产生资源竞争,proxy 需要需要具备隔离的能力。


2.1.2 smart-client 模式


业务代码需要进行一些改造,引入支持读写分离或者分库分表的功能的 SDK,这个就是我们的 smart-client。通常 smart-client 是在连接池或者 Driver 的基础上进行了一层封装,smart-client 内部与不同的库建立连接。应用程序产生的 SQL 交给 smart-client 进行处理,其内部对 SQL 进行必要的操作,例如在读写分离情况下,选择走从库还是主库;在分库分表的情况下,进行 SQL 解析、SQL 改写等操作,然后路由到不同的分库,将得到的结果进行合并,返回给应用。


优点:


1) 实现简单


proxy 需要实现数据库的服务端协议,但是 smart-client 不需要实现客户端通信协议。原因在于,大多数据数据库厂商已经针对不同的语言提供了相应的数据库驱动 Driver,例如 MySQL 针对 Java 语言提供了 mysql-connector-java 驱动,针对 Python 提供了 mysql-connector-python 驱动,客户端的通信协议已经在 Driver 层面做过了。因此 smart-client 模式的中间件,通常只需要在此基础上进行封装即可。


2) 天然去中心化


smart-client 的方式,由于本身以 SDK 的方式,被应用直接引入,随着应用部署到不同的节点上,且直连数据库,中间不需要有代理层。因此相较于 proxy 而言,除了网络资源之外,基本上不存在任何其他资源的竞争,也不需要考虑高可用的问题。只要应用的节点没有全部宕机,就可以访问数据库(这里的高可用是相比 proxy 而言,数据库本身的高可用还是需要保证的)。


缺点:


1) 通常仅支持某一种语言


例如 tddl、zebra、sharding-jdbc 都是使用 Java 语言开发,因此对于使用其他语言的用户,就无法使用这些中间件。如果其他语言要使用,那么就要开发多语言客户端。


2) 版本升级困难


因为应用使用数据源代理就是引入一个 jar 包的依赖,在有多个应用都对某个版本的 jar 包产生依赖时,一旦这个版本有 bug 所有的应用都需要升级。而数据库代理升级则相对容易,因为服务是单独部署的,只要升级这个代理服务器,所有连接到这个代理的应用自然也就相当于都升级了。


2.2 业界产品


无论是 proxy,还是 smart-client,二者的作用都是类似的。以下列出了这两种方案目前已有的实现以及各自的优缺点:



proxy 实现


目前的已有的实现方案有:


  • 阿里巴巴开源的 Cobar

  • 阿里云上的 Drds

  • MyCAT 团队在 Cobar 基础上开发的 MyCAT

  • MySQL 官方提供的 mysql-proxy

  • 奇虎 360 在 mysql-proxy 基础开发的 Atlas(只支持分表,不支持分库)

  • 当当网开源的 sharing-sphere


目前除了 MyCAT、sharing-sphere,其他几个开源项目基本已经没有维护,sharing-sphere 前一段时间已经进去了 Apache 软件基金会孵化器。


smart-client 实现


目前的实现方案有:


  • 阿里巴巴开源的 tddl,已很久没维护

  • 大众点评开源的 zebra,大众点评的 zebra 开源版本代码已经很久没有更新,不过最近美团上市,重新开源大量内部新的功能特性,并计划长期维持。

  • 当当网开源的 sharding-jdbc,目前算是做的比较好的,文档资料比较全。和 sharding-sphere 一起进入了 Apache 孵化器。

  • 蚂蚁金服的 zal

  • 等等


3 读写分离核心要点


3.1 基本路由功能


基本路由路功能主要是解决,在读写分离的情况下,如何实现一些基本的路由功能,这个过程通常可以通过下图进行描述:


3.1.1 SQL 类型判断


主要是判断出来 SQL 是读还是写 SQL,将读 SQL 到从库上去执行,写 SQL 去主库上执行


  • write 语句:insert、update、delete、create、alter、truncate…

  • query 语句:select、show、desc、explain… 


3.1.2 强制走主库


有的时候,对于一些强一致性的场景,需要写入后,必须能读取到数据。由于主从同步存在延迟,可能会出现主库写入,而从库查不到的情况。这次时候,我们需要使用强制走主库的功能。具体实现上有2种方案:hint 或 API hint,就是开发人员在 SQL 上做一些特殊的标记,数据库中间件识别到这个标记,就知道这个 SQL 需要走主库,如: 


/*master*/ select * from table_xx

这里的 /*master*/ 就是一个 hint,表示需要走主库。不同的数据库中间件强制走主库的hint可能不同,例如 zebra 的 hint 为 /*zebra:w+*/。hint 到底是什么样是无所谓的,其作用仅仅就是一个标记而已。之所以将 hint 写在 /*…*/ 中,是因为这是标准的 SQL 注释语法。即使数据库中间件未能识别这个 hint,也不会导致 SQL 语法错误。


API:主要是通过代码的方式来添加 SQL 走主库的标识,hint 通常只能加在某个 SQL 上。如果我们希望多个 SQL 同时都走主库,也不希望加 hint,则可以通过 API 的方式,其内部主要利用语言的 thread local 线程上下文特性,如:


ForceMasterHelper.forceMaster() //…执行多条sqlForceMasterHelper.clear()

在 API 标识范围内执行的 SQL,都会走主库。具体 API 到底应该是什么样,如何使用,也是由相应的数据库中间件来决定的。


特别的,对于一些特殊的 SQL,例如 select last_insert_id;或者 select @@identity 等,这类 SQL 总是需要走主库。这些 SQL 是要获得最后一个插入记录的 id,插入操作只可能发生在主库上。


3.2 从库路由策略


通常在一个集群中,只会有一个 Master,但是有多个 Slave。当判断是一个读请求时,如何判断选择哪个 Slave 呢?


一些简单的选择策略包括:


  • 随机选择(random)

  • 按照权重进行选择(weight)

  • 轮询(round-robin)

  • 等等


特别的,对于一些跨IDC(数据中心)部署的数据库集群,通常需要有就近路由的策略,如下图: 



图中,在 IDC2 部署了一个 Master,在 IDC1 和 IDC2 各部署了一个 Slave,应用 App 部署在 IDC1。显然当 App 接收到一个查询请求时,应该优先查询与其位于同一个数据中心的 Slave1,而不是跨数据中心去查询 Slave2,这就是就近路由的概念。


当然一个数据中心内,可能会部署多个 Slave,也需要进行选择,因此就近路由通常和一些基本的路由策略结合使用。另外,对于就近路由,通常也会有一个层级,例如同机房、同中心、同区域、跨区域等。 


3.3 HA、Scalable 相关


数据库中间件除了需要具备上述提到的读写分离功能来访问底层的数据库集群。也需要一套支持高可用、动态扩展的体系:


  • 从 HA 的角度来说,例如主库宕机了,那么应该从从库选择一个作为新的主库。开源的 MHA 可以帮助我们完成这个事;然而,MHA 只能在主库宕机的情况下,完成主从切换,对于仅仅是一个从库宕机的情况下,MHA 通常是无能为力的。因此,通常都会在 MHA 进行改造,使其支持更多的 HA 能力要求。

  • 从 Scalable 角度来说,例如读 QPS 实在太高,需要加一些从库,来分担读流量。


事实上,无论是 HA,还是 Scalable,对于数据库中间件(不论是 proxy 或者 smart-client)来说,只是配置信息发生了变更。 


因此,通常我们会将所有的配置变更信息写到一个配置中心,然后配置心中监听这个配置的变更,例如主从切换,只需要把最新的主从信息设置到配置中心;增加从库,把新从库 IP、Port等信息放到配置中心。数据库中间件通过对这些配置信息变更进行监听,当配置发生变更时,实时的应用最新的配置信息即可。


因此,一个简化的数据库中间件的高可用架构通常如下所示: 



监控服务对集群进行监控,当发生变更时,将变更的信息 push 到配置中心中,数据库中间件(proxy 或 smart-client)接收到配置变更,应用最新的配置。而整个过程,对于业务代码基本是无感知的。


对于配置中心的选择,有很多,例如百度的 disconf、阿里的 diamond、点评开源的 lion、携程开源的 apollo等,也可以使用 etcd、consul。通常如果没有历史包袱的话,建议使用携程开源的 apollo。


特别需要注意的一点是,通常监控服务监控到集群信息变更,推送到配置中心,再到数据库中间件,必然存在一些延迟。对于一些场景,例如主从切换,没有办法做到彻底的业务无感知。当然,对于多个从库中,某个从库宕机的情况下,是可以做到业务无感知的。例如,某个从库失败,数据库中间件,自动从其他正常的从库进行重试。


另外,上图中的 HA 方案强依赖于配置中心,如果某个数据库集群上建立了很多库,这个集群发生变更时,将会存在大量的配置信息需要推送。又或者,如果数据库集群是多机房部署的,在某个机房整体宕机的情况下(例如光纤被挖断了或者机房宕机演练),也会存在大量的配置信息需要推送。如果配置中心,推送有延迟,业务会有非常明显的感知。


因此,通常我们会在客户端进行一些轻量级的HA保障。例如,根据数据库返回异常的 sqlstate 和 vendor code,判断异常的严重级别,确定数据库实例能否正常提供服务,如果不能正常提供服务,则自动将其进行隔离,并启动异步线程进行检测数据库实例是否恢复。


最后,很多数据库中间件,也会提供一些限流和降级的功能,计算 SQL 的唯一标识(有些称之为 SQL 指纹),对于一些烂 SQL,导致数据库压力变大的情况,可以实时的进行拦截,直接抛出异常,不让这些 SQL 打到后端数据库上去。 


4 分库分表核心要点


从业务开发的角度来说,其不关心底层是否是分库分表了,其还是希望想操作单个数据库实例那样编写 SQL,那么数据库中间件就需要对其屏蔽所有底层的复杂逻辑。


下图演示了一个数据库表(user 表)在分库分表情况下,数据库中间件内部是如何执行一个批量插入 SQL 的:



数据库中间件主要对应用屏蔽了以下过程:


  • SQL 解析:首先对 SQL 进行解析,得到抽象语法树,从语法树中得到一些关键 SQL 信息;

  • SQL 路由:SQL 路由包括库路由和表路由。库路由用于确定这条记录应该操作哪个分库,表路由用于确定这条记录应该操作哪个分表;

  • SQL 改写:将 SQL 改写成正确的执行方式。例如,对于一个批量插入 SQL,同时插入4条记录。但实际上用户希望 4 个记录分表存储到一个分表中,那么就要对 SQL 进行改写成 4 条 SQL,每个 SQL 都只能插入 1 条记录;

  • SQL 执行:一条 SQL 经过改写后可能变成了多条 SQL,为了提升效率应该并发的去执行,而不是按照顺序逐一执行;

  • 结果集合并:每个 SQL 执行之后,都会有一个执行结果,我们需要对分库分表的结果集进行合并,从而得到一个完整的结果。 


4.1 SQL 解析


用户执行只是一条 SQL,并传入相关参数。数据库中间件内部需要通过 SQL 解析器,对 SQL 进行解析。可以将 SQL 解析,类比为 XML 解析,XML 解析的最终结果是得到一个 document 对象,而 SQL 解析最终得到一个抽象语法树(AST)。通过这个语法树,我们可以很简单的获取到 SQL 的一些执行,例如当前执行的 SQL 类型,查询了那些字段、数据库表名、where 条件、SQL 的参数等一系列信息。


通常来说,对于 SQL 解析,内部需要经过词法(lex)解析和语法(Syntax)解析两个阶段,最终得到一个语法树。 



SQL 解析器的内部实现原理对业务同学是屏蔽的,业务同学也感知不到。一些数据库中间件采用了第三方开源的 SQL 解析器,也有一些自研 SQL 解析器。例如 mycat、zebra 采用的都是 druid 解析器,shard-jdbc 一开始也用的是 druid 解析器,后面自研了解析器。目前较为流行的sql解析器包括:


  • FoundationDB SQL Parser

  • Jsqlparser

  • Druid SQL Parser


其中,其中 Fdbparser 和 jsqlparser 都是基于 javacc 实现的。


MyCAT 团队曾经做过一个性能测试,Druid 解析器的解析性能通常能达到基于 javacc 生成的 SQL 解析器 10~20 倍。本人也进行过类似的测试,得出的结论基本一致。


如何对比不同的 SQL 解析器的好坏呢?主要是考虑以下两点:


解析性能:Druid 最好。


Druid 采用的是预测分析法,它只需要从字符的第一个到最后一个遍历一遍,就同时完成了词法解析和语法解析,语法树也已经构造完成。


数据库方言:Druid 支持的最多。


SQL-92、SQL-99 等都是标准 SQL,mysql/oracle/pg/sqlserver/odps 等都是方言,sql-parser 需要针对不同的方言进行特别处理。Druid 的 sql parser是 目前支持各种数据语法最完备的 SQL Parser。


注:这里说的仅仅是基于 Java 实现的 SQL 解析器,Druid 是比较好的。大部分同学可能知道 Druid 是一个为监控而生的连接池,事实上,Druid 另一大特性就是它的 SQL 解析器。很多开源的数据库中间件,例如 zebra、sharding-jdbc 等,都使用了 Druid 解析器。(sharding-jdbc后来自研了解析器)。虽然SQL解析是druid的一大亮点,不过 GitHub 上也因为 SQL 解析的 bug,收到了不少 issue。


4.2 SQL 路由


路由规则是分库分表的基础,其规定了数据应该按照怎样的规则路由到不同的分库分表中。对于一个数据库中间件来说,通常是支持用户自定义任何路由规则的。路由规则本质上是一个脚本表达式,数据库中间件通过内置的脚本引擎对表达式进行计算,确定最终要操作哪些分库、分表。常见的路由规则包括哈希取模,按照日期等。


下图展示了 user 表进行分库分表后(2 个分库,每个分库 2 个分表),并如何根据id进行路由的规则: 



路由分则分为:


  • 库规则:用于确定到哪一个分库

  • 表规则:用于确定到哪一个分表


在上例中,我们使用id来作为计算分表、分表,因此把id字段就称之为路由字段,或者分区字段。


需要注意的是,不管执行的是 INSERT、UPDATE、DELETE、SELECT语句,SQL 中都应该包含这个路由字段。否则,对于插入语句来说,就不知道插入到哪个分库或者分表;对于 UPDATE、DELETE、SELECT 语句而言,则更为严重,因为不知道操作哪个分库分表,意味着必须要对所有分表都进行操作。SELECT 聚合所有分表的内容,极容易内存溢出,UPDATE、DELETE 更新、删除所有的记录,非常容易误更新、删除数据。因此,一些数据库中间件,对于 SQL 可能有一些限制,例如 UPDATE、DELETE 必须要带上分区字段,或者指定过滤条件。 


4.3 SQL 改写


前面已经介绍过,如一个批量插入语句,如果记录要插入到不同的分库分表中,那么就需要对 SQL 进行改写。 例如,将以下 SQL:


insert into user(id,name) values (1,”tianshouzhi”),(2,”huhuamin”), (3,”wanghanao”),(4,”luyang”)

改写为:


insert into user_1(id,name) values (1,”tianshouzhi”)insert into user_2(id,name) values (2,”huhuamin”)insert into user_3(id,name) values (3,”wanghanao”)insert into user_0(id,name) values (4,”luyang”)


这里只是一个简单的案例,通常对于 INSERT、UPDATE、DELETE 等,改写相对简单。比较复杂的是 SELECT 语句的改写,对于一些复杂的 SELECT 语句,改写过程中会进行一些优化,例如将子查询改成 JOIN,过滤条件下推等。因为 SQL 改写很复杂,所以很多数据库中间件并不支持复杂的 SQL(通常有一个支持的 SQL),只能支持一些简单的 OLTP 场景。


当然也有一些数据库中间件,不满足于只支持 OLTP,在迈向 OLAP 的方向上进行了更多的努力。例如阿里的 TDDL、蚂蚁的 Zdal、大众点评的 zebra,都引入了 apache calcite,尝试对复杂的查询 SQL(例如嵌套子查询,join 等)进行支持,通过过滤条件下推,流式读取,并结合 RBO(基于规则的优化)、CBO(基于代价的优化)来对一些简单的 OLAP 场景进行支持。


4.4 SQL 执行


当经过 SQL 改写阶段后,会产生多个 SQL,需要到不同的分片上去执行,通常我们会使用一个线程池,将每个 SQL 包装成一个任务,提交到线程池里面并发的去执行,以提升效率。



这些执行的 SQL 中,如果有一个失败,则整体失败,返回异常给业务代码。 


4.5 结果集合并


结果集合并,是数据库中间件的一大难点,需要 case by case 的分析,主要是考虑实现的复杂度,以及执行的效率问题,对于一些复杂的 SQL,可能并不支持。例如:


对于查询条件:大部分中间件都支持 =、IN 作为查询条件,且可以作为分区字段。但是对于 NIT IN、BETWEEN…AND、LIKE、NOT LIKE 等,只能作为普通的查询条件,因为根据这些条件,无法记录到底是在哪个分库或者分表,只能全表扫描。


聚合函数:大部分中间件都支持 MAX、MIN、COUNT、SUM,但是对于 AVG 可能只是部分支持。另外,如果是函数嵌套、分组(GROUP BY)聚合,可能也有一些数据库中间件不支持。


子查询:分为 FROM 部分的子查询和 WHERE 部分的子查询。大部分中对于子查询的支持都是非常有限,例如语法上兼容,但是无法识别子查询中的分区字段,或者要求子查询的表名必须与外部查询表名相同,又或者只能支持一级嵌套子查询。


JOIN:对于 JOIN 的支持通常很复杂,如果做不到过滤条件下推和流式读取,在中间件层面,基本无法对 JOIN 进行支持,因为不可能把两个表的所有分表,全部拿到内存中来进行 JOIN,内存早就崩了。当然也有一些取巧的办法,一个是 Binding Table,另外一个是小表广播(见后文)。


分页排序:通常中间件都是支持 ORDER BY 和 LIMIT 的。但是在分库分表的情况下,分页的效率较低。例如对于 limit 100,10 ORDER BY id。表示按照 id 排序,从第 100 个位置开始取10条记录。那么,大部分数据库中间件实际上是要从每个分表都查询 110(100+10) 条记录,拿到内存中进行重新排序,然后取出 10 条。假设有 10 个分表,那么实际上要查询 1100 条记录,而最终只过滤出了 10 记录。因此,在分页的情况下,通常建议使用 "where id > ? limit 10” 的方式来进行查询,应用记住每次查询的最大的记录 id。之后查询时,每个分表只需要从这个 id 之后,取 10 条记录即可,而不是取 offset + rows 条记录。 


关于 JOIN 的特殊说明:


Binding Table


适用于两个表之间存在关联关系,路由规则相同。例如,有 user 表和 user_account 表,由于 user_account 与 user 表强关联,我们可以将这两个表的路由规则设置为完全一样,那么对于某个特定用户的信息,其所在的 user 分表和 user_account 分表必然唯一同一个分库下,后缀名相同的分表中。在 join 时,某一个分库内的 join,就可以拿到这个用户以及账号的完整信息,而不需要进行跨库 join,这样就不需要把用户的数据库拿到内存中来进行 join。 




小表广播


小表广播通常是某一个表的数据量比较少, 例如部门表 department。另外一个表数据量比较大,例如 user。此时 user 需要进行分库分表,但是 department 不需要进行分库分表。为了达到 JOIN 的目的,我们可以将 department 表在每个分库内都实时同步一份完整的数据。这样,在 JOIN 的时候,数据库中间件只需要将分库 JOIN 的结果进行简单合并即可。


下图演示了小表广播的流程,用户在更新 department 表时,总是更新分库 db0 的 department 表,同步组件将变更信息同步到其他分库中。 




注:图中的同步组件指的是一般是伪装成数据库的从库,解析源库 binlog,插入目标库。有一些开源的组件,如 canal、puma 可以实现这个功能,当然这些组件的应用场景非常广泛,不仅限于此。


4.6 二级索引


通常情况下,分库分表的时候,分区字段只有一个。例如对于用户表 user,按照 user_id 字段进行分区,那么之后查询某个用户的信息,只能根据 user_id 作为分区字段。使用其他字段,则需要扫描所有分表,效率很低。但是又有根据其他字段查询某个用户信息的需求,例如根据手机号 phone_id。


此时,我们可以将按照 user_id 插入的数据,进行一份全量拷贝。通过同步组件,重新按照 phone_id 插入到另一个分库分表集群中,这个集群就成为二级索引,或者叫辅维度同步。此后,对于根据 user_id 的操作,就在原来的分库分表集群中进行操作;根据 phone_id 的操作,就到二级索引集群中去进行操作。


需要注意的是,对于更新操作,只能操作原集群,二级索引集群只能执行查询操作。原集群的增量数据变更信息,实时的通过同步组件,同步到二级索引集群中。 



注:这是一个很常见的面试题。阿里的一些面试官,比较喜欢问。一些面试者,可能自己想到了这个方案,因为考虑到这样比较浪费资源,就自行排除了。事实上,这点资源相对于满足业务需求来说,都不是事。


4.7 分布式 ID 生成器


在分库分表的情况下,数据库的自增主键已经无法使用。所以要使用一个分布式的 id 生成器。分布式事务id生成器要满足以下条件:唯一、趋势递增(减少落库时的索引开销)、高性能、高可用。


目前主流的分布式id生成方案都有第三方组件依赖,如:


  • 基于 ZooKeeper

  • 基于 MySQL

  • 基于缓存


Twitter 的 Snowflake 算法是一个完全去中心化的分布式id算法,但是限制 workid 最多能有 1024,也就是说,应用规模不能超过 1024。虽然可以进行细微的调整,但是总是有数量的限制。 


另外,美团之前在 GitHub 开源了一个 leaf 组件,是用于生成分布式 id 的,感兴趣的读者可以研究一下。


这里提出一种支持动态扩容的去中心化分布式 id 生成方案,此方案的优势,除了保证唯一、趋势递增,没有第三方依赖,支持存储的动态扩容之外,还具有以下优势:


  • 支持按照时间范围查询,或者 时间范围+ IP 查询,可以直接走主键索引;

  • 每秒的最大序列id就是某个 IP 的 QPS 等


12位日期 + 10位IP + 6位序列ID + 4位数据库扩展位

其中:


12 位日期:格式为 yyMMddHHmmss,意味着本方案的id生成策略可以使用到 2099 年,把时间部分前置,从而保证趋势递增。


10 位 IP:利用 IP to decimal 算法将 12 位的 IP 转为 10 进制数字。通过 IP 地址,来保证全局唯一。如果 IP 地址被回收重复利用了,也不用担心 id 的唯一性,因为日期部分还在变化。


6 位序列 id:意味着每秒最多支持生成 100 百万个 id(0~999999)。不足 6 位前置补 0,如 000123。


4 位数据库扩展位:为了实现不迁移数据的情况下,实现动态扩容,其中 2 位表示 DB,2 位表示 TB,最多可扩容到 10000 张表。假设每张表存储 1000 万数据,则总共可以支持存储 1000 亿条数据。 


关于数据库扩展位实现动态扩容图解:



首先明确一点,路由策略始终根据数据库最后四位,确定某一条记录要到哪个分库的哪个分表中。例如 xxxx0001,意味着这条记录肯定是在 00 分库的 01 分表上。


接着,就要在 id 的生成策略上做文章。


假设初始状态为两个分库 db_00、db_01,每个分库里面有 10 张分表, tb_00~tb_09。此时,业务要保证生成 id 的时候,始终保证 DB 的两位在 00~01 之间,tb 的两位始终在 00~09 之间。路由策略根据这些id,可以找到正确的分库分表。


现在需要扩容到 10 个分库,每个分表 10 个分表。那么 DBA 首先将新增的分库:


db_02~db_09 创建好,每个分库里面再创建 10 个分表:tb_01~tb_09。业务同学在此基础上,将 id 生成策略改成:db 的两位在 00~09 之间,tb 的两位规则维持不变(只是分库数变了,每个分库的分表数没变)。而由于路由从策略是根据最后四位确定到哪个分库,哪个分表,当这些新的分库分表扩展位id出现时,自然可以插入到新的分库分表中。也就实现了动态扩容,而无需迁移数据。


当然,新的分库分表中,一开始数据是没有数据的,所以数据是不均匀的,可以调整 id 扩展位中 db 和 tb 生成某个值的概率,使得落到新的分库分表中的概率相对大一点点(不宜太大),等到数据均匀后,再重新调整成完全随机。


此方案的核心思想是,预分配未来的可能使用到的最大资源数量。通常,100 个分库,每个分库 100 张分表,能满足绝大部分应用的数据存储。如果 100 个分库都在不同的 MySQL 实例上,假设每个 MySQL 实例都是 4T 的磁盘,那么可以存储 400T 的数据,基本上可以满足绝大部分业务的需求。


当然,这个方案不完美。如果超过这个值,这种方案可能就不可行了。然而,通常一个技术方案,可以保证在 5~10 年之间不需要在架构上做变动,应该就算的上一个好方案了。如果你追求的是完美的方案,可能类似于 TiDB 这种可以实现自动扩容的数据库产品更适合,不过目前来说,TiDB等类似产品还是无法取代传统的关系型数据库的。说不定等到 5~10 年后,这些产品更成熟了,你再迁移过去也不迟。


4.7 分布式事务


在分库分表的情况下,由于操作多个分库,此时就涉及到分布式事务。例如执行一个批量插入 SQL,如果记录要插入到不同的分库中,就无法保证一致性。因此,通常情况下,数据库中间件,只会保证单个分库的事务,也就是说,业务方在创建一个事务的时候,必须要保证事务中的所有操作,必须最终都在一个分库中执行。


事实上,在微服务的架构下,事务的问题更加复杂,如下图:



Service A 在执行某个操作时,需要操作数据库,同时调用 Service B 和 Service C。Service B 底层操作的数据库是分库分表的,Service C 也要操作数据库。


这种场景下,保证事务的一致性就非常麻烦。一些常用的一致性算法如:paxios 协议、raft 协议也无法解决这个问题,因为这些协议都是资源层面的一致性。在微服务架构下,已经将事务的一致性上升到了业务的层面。


如果仅仅考虑分库分表,一些同学可能会想到 XA,但是性能很差,对数据库的版本也有要求,例如必须使用 MySQL 5.7,官方还建议将事务隔离级别设置为串行化,这是无法容忍的。


由于分布式事务的应用场景,并不是仅仅分库分表,因此通常都是会有一个专门的团队来做分布式事务,并不一定是数据库中间件团队来做。例如,sharding-jdbc 就使用了华为开源的一套微服务架构解决方案 service comb 中的 saga 组件,来实现分布式事务最终一致性。阿里也有类似的组件,在内部叫 TXC,在阿里云上叫 GTS,最近开源到了 GitHub 上叫 fescar(Fast & Easy Commit And Rollback)。蚂蚁金服也有类似的组件,叫 DTX,支持 FMT 模式和 TCC 模式。其中 FMT 模式就类似于 TXC。


总体来说,实际上 TCC 更能满足业务的需求,虽然接入更加复杂。关于 fescar,最近比较火,这是 Java 写的,具体可以参考:https://github.com/alibaba/fescar。 


- EOF -

推荐阅读  点击标题可跳转

1、如何写一个读写分离中间件

2、消息中间件该如何实现高可用架构?

3、一款优秀数据库中间件的不完全解析


看完本文有收获?请转发分享给更多人

关注「ImportNew」,提升Java技能

点赞和在看就是最大的支持❤️



您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存